Java 并发基础(二)
线程安全问题
线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题。线程 A 和线程 B 可以同时操作主内存中的共享变量,那么线程安全问 题和共享资源之间是什么关系呢?是不是说多个线程共享了资源,当它们都去访问这个共 享资源时就会产生线程安全问题呢?答案是否定的,如果多个线程都只是读取共享资源,而不去修改,那么就不会存在线程安全问题,只有当至少一个线程修改共享资源时才会存 在线程安全问题。最典型的就是计数器类的实现,计数变量 count 本身是一个共享变量,多个线程可以对其进行递增操作,如果不使用同步措施,由于递增操作是获取—计算—保存三步操作,因此可能导致计数不准确,如下所示。

- 假如当前 count=0,在 t1 时刻线程 A 读取 count 值到本地变量 countA。
- 然后在 t2 时 刻递增 countA 的值为 1,同时线程 B 读取 count 的值 0 到本地变量 countB,此时 countB 的值为 0(因为 countA 的值还没有被写入主内存)。
- 在 t3 时刻线程 A 才把 countA 的值 1 写入主内存,至此线程 A 一次计数完毕,同时线程 B 递增 CountB 的值为 1。
- 在 t4 时刻 线程 B 把 countB 的值 1 写入内存,至此线程 B 一次计数完毕。
这里先不考虑内存可见性问题,明明是两次计数,为何最后结果是 1 而不是 2 呢 ? 其实这就是共享变量的线程安全问题。
共享变量的内存及可见性问题
Java内存模型(JMM)中的 “工作内存” 是一个抽象概念,它并等价于JVM运行时数据区中的 “栈内存 ”或 “堆内存”。两者的视角和目标完全不同:
- JVM运行时数据区(栈、堆、方法区等)描述的是内存物理(或逻辑)的划分。它回答的是 “变量、对象、类信息实际被存放在内存的哪个区域”。
- Java内存模型(JMM) 是一个抽象的概念模型、一套规则和约定。它定义了线程和主内存之间的交互关系,目的是解决在多线程环境下,由于缓存、指令重排序等带来的可见性、有序性问题。它回答的是 “在多线程场景下,对变量的读写操作应该遵循什么规则才能保证正确性”。
打个比方:
- JVM 运行时数据区像是仓库的物理布局图:哪里是原料区(堆),哪里是工人的私人工作台(栈),哪里是公共图纸区(方法区)。
- Java 内存模型像是公司的物料领用和归档规定:工人(线程)需要从中心仓库(主内存)领取原料(变量副本)到自己的工位(工作内存)操作,操作后必须按特定流程将变更同步回中心仓库。
规定(JMM)会利用布局(运行时数据区),但并不等于布局。那么所谓的 “工作内存” 具体可能包含什么呢?根据JMM的规定和实际实现,一个线程的 “工作内存” 可能对应并包含以下几部分物理内存:
- 线程私有的栈帧中的局部变量表:这是最主要的部分。你方法里定义的局部变量(包括基本类型和对象引用)的操作,都发生在这里。
- CPU的高速缓存:这是硬件层面的优化。为了极致性能,CPU会将从主内存读取的数据缓存到各级Cache(L1/L2/L3)中。JMM的抽象 “工作内存” 在硬件层面的体现之一就是这些CPU缓存。不同线程可能运行在不同的CPU核心上,各自拥有独立的缓存,这直接导致了缓存不一致,也就是
可见性问题的核心根源。 - 可能还包括寄存器:程序计数器等寄存器也用于存储最即时的操作数。
所以,JMM的 “工作内存” 是一个涵盖了硬件和软件多个层面的综合抽象。它主要指线程私有、且能进行快速数据存取的存储区域。一个具体的例子来澄清混淆点:
1 | public class Example { |
- 从JVM运行时数据区看:sharedState 这个成员变量,作为一个对象实例的一部分,始终存在于堆内存中。localTemp 和 i 则存在于线程私有的栈内存中
- 从Java内存模型看:线程想要修改 sharedState,它并不是直接去堆里改。JMM规定它必须先从主内存(主内存包含了堆中这个变量的 “权威副本”)获取这个变量,放入自己的工作内存(这个 “放入” 操作,在物理上就对应着数据从主存加载到CPU缓存/寄存器),在工作内存中修改完毕后,再在某个时机将新值刷新回主内存。
如果两个线程并发执行 actorThread,各自在自己的工作内存(CPU缓存)中完成了sharedState++,但都没有及时将新值刷新回主内存,或者没有从主内存重新读取最新值,就会导致最终结果小于 20000。这就是可见性问题。
通常,当你思考 synchronized、volatile、happens-before 这些并发关键字和规则时,应该在 Java内存模型的框架下思考 “主内存” 和 “工作内存” 的交互。当你思考一个对象或变量 “存在哪里” 时,你才去对应JVM运行时数据区的堆或栈。
共享变量副本
共享变量副本
注:JMM 决定一个线程对共享变量的写入何时对另一个线程可见。
注:私有内存(工作内存)对应 CPU 寄存器和缓存;主内存对应硬件内存。
Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内 存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工 作内存中的变量。Java 内存模型是一个抽象的概念,那么在实际实现中线程的工作内存是 什么呢?
如上面右图所示是一个双核 CPU 系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算。每个核都有自己的一级缓存, 在有些架构里面还有一个所有 CPU 都共享的二级缓存。那么 Java 内存模型里面的工作内存,就对应这里的 L1 或者 L2 缓存或者 CPU 的寄存器。
当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理 ,处理完后将变量值更新到主内存。那么假如线程 A 和线程 B 同时处理一个共享变量,会出现什么情况?我们使用上图所示 CPU 架构,假设线程 A 和线程 B 使用不同 CPU 执行,并且当前两级 Cache 都为空, 那么这时候由于 Cache 的存在,将会导致内存不可见问题,具体看下面的分析。
- 线程 A 首先获取共享变量 X 的值,由于两级 Cache 都没有命中,所以加载主内存 中 X 的值,假如为 0。然后把 X=0 的值缓存到两级缓存,线程 A 修改 X 的值为 1, 然后将其写入两级 Cache,并且刷新到主内存。线程 A 操作完毕后,线程 A 所在的 CPU 的两级 Cache 内和主内存里面的 X 的值都是 1。
- 线程 B 获取 X 的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了, 所以返回 X= 1 ;到这里一切都是正常的,因为这时候主内存中也是 X=1。然后线程 B 修改 X 的值为 2,并将其存放到线程 2 所在的一级 Cache 和共享二级 Cache 中, 最后更新主内存中 X 的值为 2。到这里一切都是好的。
- 线程 A 这次又需要修改 X 的值,获取时一级缓存命中,并且 X=1,到这里问题就出现了,明明线程 B 已经把 X 的值修改为了 2,为何线程 A 获取的还是 1 呢?这就是共享变量的内存不可见问题,也就是线程 B 写入的值对线程 A 不可见。
那么如何解决共享变量内存不可见的问题 ? 使用 Java 中的 volatile 关键字就可以解决这 个问题。
synchronized 解可见性问题
所谓 synchronized 块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作 一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。 线程的执行代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该 同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后 或者在同步块内调用了该内置锁资源的 wait 系列方法时释放该内置锁。内置锁是排它锁, 也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。另外,由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的使用就会导致上下文切换。
为什么 synchronized 是一种解决内存可见性的方式呢?因为进入 synchronized 块的内存语义就是把在 synchronized 块内使用到的变量从线程的工作内存中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是 直接从主内存中获取。退出 synchronized 块的内存语义是把在 synchronized 块内对共享变 量的修改刷新到主内存。其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共 享变量刷新到主内存。
除可以解决共享变量内存可见性问题外,synchronized 经常被用来实现原子性操作。 另外请注意,synchronized 关键字会引起线程上下文切换并带来线程调度开销。
volatile 解可见性问题
上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因 为它会带来线程上下文的切换开销。对于解决内存可见性问题,Java 还提供了一种弱形式 的同步,也就是使用 volatile 关键字。该关键字可以确保对一个变量的更新对其他线程马 上可见。当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者 其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获 取最新值,而不是使用当前线程的工作内存中的值。volatile 的内存语义和 synchronized 有 相似之处,具体来说就是,当线程写入了 volatile 变量值时就等价于线程退出 synchronized 同步块(把写入工作内存的变量值同步到主内存),读取 volatile 变量值时就相当于进入同 步块(先清空本地内存变量值,再从主内存获取最新值)。
1 | public class ThreadNotSafeInteger { |
先来看使用 synchronized 关键字进行同步的方式。
1 | public class ThreadSafeInteger { |
然后是使用 volatile 进行同步。
1 | public class ThreadSafeInteger { |
在这里使用 synchronized 和使用 volatile,都解决了共享变量 value 的内存可 见性问题,但是前者是独占锁,同时只能有一个线程调用 get() 方法,其他调用线程会被阻塞, 同时会存在线程上下文切换和线程重新调度的开销,这也是使用锁方式不好的地方。而后 者是非阻塞算法,不会造成线程上下文切换的开销。需要注意,volatile 虽然提供了可见性保证,但并不保证操作的原子性。
原子性操作与写安全
所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行, 不存在只执行其中一部分的情况。在设计计数器时一般都先读取当前值,然后 +1,再更新。 这个过程是读—改—写的过程,如果不能保证这个过程是原子性的,那么就会出现线程安 全问题。如下代码是线程不安全的,因为不能保证 ++value 是原子性操作。
1 | public class ThreadNotSafeCount { |
使用 Javap -c 命令查看汇编代码,可以看到,Java 中简单的一句 ++value 被转换为汇编后就不具有原子性了。
1 | public void inc(); |
那么如何才能保证多个操作的原子性呢?最简单的方法就是使用 synchronized 关键字 进行同步,修改代码如下。
1 | public class ThreadNotSafeCount { |
使用 synchronized 关键字的确可以实现线程安全性,即内存可见性和原子性,但是 synchronized 是独占锁,没有获取内部锁的线程会被阻塞掉,而这里的 getCount 方法只是 读操作,多个线程同时调用不会存在线程安全问题。但是加了关键字 synchronized 后,同 一时间就只能有一个线程可以调用,这显然大大降低了并发性。你也许会问,既然是只读 操作,那为何不去掉 getCount 方法上的 synchronized 关键字呢?其实是不能去掉的,别忘 了这里要靠 synchronized 来实现 value 的内存可见性。那么有没有更好的实现呢?答案是 肯定的,下面将讲到的在内部使用非阻塞 CAS 算法实现的原子性操作类 AtomicLong 就是 一个不错的选择。
Java 中的 CAS 操作
在 Java 中,锁在并发处理中占据了一席之地,但是使用锁有一个不好的地方,就 是当一个线程没有获取到锁时会被阻塞挂起,这会导致线程上下文的切换和重新调度开 销。Java 提供了非阻塞的 volatile 关键字来解决共享变量的可见性问题,这在一定程度 上弥补了锁带来的开销问题,但是 volatile 只能保证共享变量的可见性,不能解决 “读—改—写” 等的原子性问题。
CAS 即 Compare and Swap,其是 JDK 提供的非阻塞原子性操 作,它通过硬件保证了比较—更新操作的原子性。JDK 里面的 Unsafe 类提供了一系列的 compareAndSwap* 方法,下面以 compareAndSwapLong 方法为例进行简单介绍。
1 | /** |
关于 CAS 操作有个经典的 ABA问题,具体为:假如线程 I 使用 CAS 修改初始值为 A 的变量 X,那么线程 I 会首先去获取当前变量 X 的值(为 A),然后使用 CAS 操作尝试修改 X 的值为 B,如果使用 CAS 操作成功了,那么程序运行一定是正确的吗?其实未必,这是因为有可能在线程 I 获取变量 X 的值 A 后,在执行 CAS 前,线程 II 使用 CAS 修改了变量 X 的值为 B,然后又使用 CAS 修改了变量 X 的值为 A。所以虽然线程 I 执行 CAS 时 X 的值是 A,但是这个 A 已经不是线程 I 获取时的 A 了。这就是 ABA 问题。
ABA 问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从 A 到 B, 然后再从 B 到 A。如果变量的值只能朝着一个方向转换,比如 A 到 B,B 到 C,不构成环形,就不会存在问题。JDK 中的 AtomicStampedReference 类给每个变量的状态值都配备了一个时间戳,从而避免了 ABA 问题的产生。
Unsafe 类
Unsafe 类提供了硬件级别的原子性操作,Unsafe 类中的方法都是 native 方法,它们使用 JNI 的方式访问本地 C++ 实现库。下面我们来了解一下 Unsafe 提供的几个主要的方法以及编程时如何使用 Unsafe 类做一些事情。
1 | // 返回指定的变量在所属类中的内存偏移地址,该偏移地址仅仅在该 Unsafe 函数中访问指定字段时使用。 |
看到 Unsafe 这个类如此厉害,你肯定会忍不住试一下下面的代码,期望能够使用 Unsafe 做点事情。
1 | import sun.misc.Unsafe; |
1 | Exception in thread "main" java.lang.ExceptionInInitializerError |
怎么回事呢?在 Java 9 之后,Unsafe.getUnsafe() 方法的调用受到了严格限制(在 Java 17+ 中,某些危险方法已被移除,例如 defineAnonymousClass)。你的代码试图通过这种方式获取 Unsafe 实例,但在非特权上下文中被拒绝了。Unsafe.getUnsafe() 方法内部有安全检查:
1 |
|
这意味着:
- 只有被启动类加载器(Bootstrap ClassLoader)加载的类才能直接调用此方法
- 你的应用类(由系统类加载器加载)无权调用
- 这是在 JDK 模块化后,强封装政策的具体体现
修复后:
1 | import sun.misc.Unsafe; |
也可以使用标准替代品 VarHandle(推荐的新方式)。VarHandle 是 Java 平台在强封装、模块化、安全性大趋势下的必然产物。它不是一把“万能钥匙”,而是一套精致、受控的专业工具。它通过绑定到具体字段、遵守访问权限、提供类型安全和精确内存语义,将原本由 Unsafe这把“屠龙刀”负责的、最常用且必要的底层并发操作(CAS、原子更新、屏障),标准化、安全化,引导开发者走向更规范、更可维护的并发编程道路。
1 | import java.lang.invoke.MethodHandles; |
指令重排序
Java 内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。下面看一个例子。
1 | /** |
再看一个多线程的例子。
1 | public class DemoTest { |
首先这段代码里面的变量没有被声明为 volatile 的,也没有使用任何同步措施,所以在多线程下存在共享变量内存可见性问题。这里先不谈内存可见性问题,因为通过把变量 声明为 volatile 的本身就可以避免指令重排序问题。这里先看看指令重排序会造成什么影响,如上代码在不考虑内存可见性问题的情况下一定会输出 4 ? 答案是不一定,由于代码(1)(2)(3)(4)之间不存在依赖关系,所以 写线程的代码(3)(4)可能被重排序为先执行(4)再执行(3),那么执行(4)后,读线程可能已经执行了(1)操作,并且在(3)执行前开始执行(2)操作,这时候输出结果为 0 而不是4。
重排序在多线程下会导致非预期的程序执行结果,而使用 volatile 修饰 ready 就可以避免重排序和内存可见性问题。写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile 写 之后。读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
伪共享
为了解决计算机系统中主内存与 CPU 之间运行速度差问题,会在 CPU 与主内存之间 添加一级或者多级高速缓冲存储器(Cache)。这个 Cache 一般是被集成到 CPU 内部的, 所以也叫 CPU Cache。在 Cache 内部是按行存储的,其中每一行称为一个 Cache 行。Cache 行(图 2) 是 Cache 与主内存进行数据交换的单位,Cache 行的大小一般为 2 的幂次数字节。
图 1:多核 CPU 缓存结构
图 2:Cache Line 结构
当 CPU 访问某个变量时,首先会去看 CPU Cache 内是否有该变量,如果有则直接从 中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个 Cache 行大 小的内存复制到 Cache 中。由于存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到一个 Cache 行中。当多个线程同时修改一个缓存行里面的多个变量时, 由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所 下降,这就是 伪共享。
图 2-8:缓存行伪共享 (False Sharing) 模型
当变量 X 和 Y 位于同一个缓存行时,不同线程修改 X 或 Y 会导致整个缓存行失效,降低并发性能。
伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变量。那么为何多个变量会被放入一个缓存行呢?其实是因为缓存与内存交换数据的单位就是缓存行,当 CPU 要访问的变量没有在缓存中找到时,根据程序运行的局部性原理,会把该变量所在内存中大小为缓存行的内存放入缓存行。
1 | long a; |
如上代码声明了四个 long 变量,假设缓存行的大小为 32 字节,那么当 CPU 访问变 量 a 时,发现该变量没有在缓存中,就会去主内存把变量 a 以及内存地址附近的 b、c、d 放入缓存行。也就是地址连续的多个变量才有可能会被放到一个缓存行中。当创建数组时, 数组里面的多个元素就会被放入同一个缓存行。那么在单线程下多个变量被放入同一个缓存行对性能有影响吗?其实在正常情况下单线程访问时将数组元素放入一个或者多个缓存行对代码执行是有利的,因为数据都在缓存中,代码执行会更快,请对比下面代码的执行。
1 | public class TestForContent1 { |
上述代码 (1) 比代码 (2) 执行得快,这是因为数组内数组元素的 内存地址是连续的,当访问数组第一个元素时,会把第一个元素后的若干元素一块放入缓存行,这样顺序访问数组元素时会在缓存中直接命中,因而就不会去主内存读取了,后续 访问也是这样。也就是说,当顺序访问数组里面元素时,如果当前元素在缓存没有命中, 那么会从主内存一下子读取后续若干个元素到缓存,也就是一次内存访问可以让后面多次访问直接在缓存中命中。而代码 (2) 是跳跃式访问数组元素的,不是顺序的,这破坏了程序访问的局部性原则,并且缓存是有容量控制的,当缓存满了时会根据一定淘汰算法替换缓存行,这会导致从内存置换过来的缓存行的元素还没等到被读取就被替换掉了。所以在单个线程下顺序修改一个缓存行中的多个变量,会充分利用程序运行的局部性原则,从而加速了程序的运行。而在多线程下并发修改一个缓存行中的多个变量时就会竞争缓存行,从而降低程序运行性能。
如何避免伪共享
在 JDK 8 之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使 用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中, 例如如下代码。
1 | public final static class FilledLong { |
假如缓存行为 64 字节,那么我们在 FilledLong 类里面填充了 6 个 long 类型的变 量,每个 long 类型变量占用 8 字节,加上 value 变量的 8 字节总共 56 字节。另外,这里 FilledLong 是一个类对象,而类对象的字节码的对象头占用 8 字节,所以一个 FilledLong 对象实际会占用 64 字节的内存,这正好可以放入一个缓存行。对此,JDK 8 提供了一个 sun.misc.Contended 注解,用来解决伪共享问题。这个注解的中作用:
- 填充(Padding): 当 JVM 看到 @Contended 注解时,它会在该字段(或类)的前后自动插入 128 字节(默认值)的空数据。
- 对齐 Cache Line: 这种填充确保了被标记的变量会独占一个或多个完整的 Cache Line(缓存行)。
- 消除冲突: 这样即便多个线程同时修改相邻的变量,也不会因为它们落入同一个缓存行而导致 MESI 协议频繁失效,从而彻底解决伪共享(False Sharing)导致的 CPU 缓存同步开销。
虽然 Java 17 保留了该注解,但官方并不鼓励普通开发者直接使用内部 API。
- 如果是在开发普通业务: 尽量通过设计数据结构来规避伪共享(比如在变量间手动添加 long p1, p2, p3, p4, p5, p6, p7; 这种填充字段)。
- 如果是在开发高性能框架: 建议参考 java.util.concurrent.atomic.LongAdder 的源码,它在 Java 17 内部依然大量使用 @Contended 来保证在高并发下的累加性能。
锁的概述
乐观锁与悲观锁
悲观锁指数据对外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加 排它锁。如果获取锁失败,则说明数据正在被其他线程修改,当前线程则等待或者抛出异 常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。
1 | public int updateEntry(long id){ |
对于如上代码 , 假设 updateEntry、query、update 方法都使用了事务切面的方法,并且事务传播性被设置为 required。执行 updateEntry 方法时如果上层调用方法里面没有开启事务,则会即时开启一个事务,然后执行代码 (1)。代码 (1) 调用了 query 方法,其根据指定 id 从数据库里面查询出一个记录。由于事务传播性为 requried,所以执行 query 时没 有开启新的事务,而是加入了 updateEntry 开启的事务,也就是在 updateEntry 方法执行完 毕提交事务时,query 方法才会被提交,就是说记录的锁定会持续到 updateEntry 执行结束。
代码 (2) 则对获取的记录进行修改,代码 (3) 把修改的内容写回数据库,同样代码 (3) 的 update 方法也没有开启新的事务,而是加入了 updateEntry 的事务。也就是 updateEntry、 query、update 方法共用同一个事务。当多个线程同时调用 updateEntry 方法,并且传递的是同一个 id 时,只有一个线程执行代码 (1) 会成功,其他线程则会被阻塞,这是因为在同一时间只有一个线程可以获取对应记录的锁,在获取锁的线程释放锁前(updateEntry 执行完毕,提交事务前),其他线程必须等待,也就是在同一时间只有一个线程可以对该记录进行修改。
乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记 录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。具 体来说,根据 update 返回的行数让用户决定如何去做。将上面的例子改为使用乐观锁的代 码如下。
1 | public int updateEntry(long id){ |
在如上代码中,当多个线程调用 updateEntry 方法并且传递相同的 id 时,多个线程可以同时执行代码(1)获取 id 对应的记录并把记录放入线程本地栈里面,然后可以同时执 行代码 (2) 对自己栈上的记录进行修改,多个线程修改后各自的 entry 里面的属性应该都 不一样了。然后多个线程可以同时执行代码 (3),代码(3)中的 update 语句的 where 条 件里面加入了 version=#{version} 条件,并且 set 语句中多了 version=${version}+1 表达式, 该表达式的意思是,如果数据库里面 id =#{id} and version=#{version} 的记录存在,则更新 version 的值为原来的值加 1,这有点 CAS 操作的意思。
假设多个线程同时执行 updateEntry 并传递相同的 id,那么它们执行代码 (1) 时获取的 Entry 是同一个,获取的 Entry 里面的 version 值都是相同的(这里假设 version=0)。当多个线程执行代码(3)时,由于 update 语句本身是原子性的,假如线程 A 执行 update 成 功了,那么这时候 id 对应的记录的 version 值由原始 version 值变为了 1。其他线程执行代 码 (3) 更新时发现数据库里面已经没有了 version=0 的语句,所以会返回影响行号 0。在 业务上根据返回值为 0 就可以知道当前更新没有成功,那么接下来有两个做法,如果业务发现更新失败了,下面可以什么都不做,也可以选择重试,如果选择重试,则 updateEntry 的代码可以修改为如下。
1 | public boolean updateEntry(long id){ |
如上代码使用 retryNum 设置更新失败后的重试次数,如果代码 (3.1) 执行后返回 0, 则说明代码 (1.1) 获取的记录已经被修改了,则循环一次,重新通过代码 (1.1) 获取最 新的数据,然后再次执行代码 (3.1) 尝试更新。这类似 CAS 的自旋操作,只是这里没有使用死循环,而是指定了尝试次数。乐观锁并不会使用数据库提供的锁机制,一般在表中添加 version 字段或者使用业务 状态来实现。乐观锁直到提交时才锁定,所以不会产生任何死锁。
公平锁与非公平锁
根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取锁 的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。 而非公平锁则在运行时闯入,也就是先来不一定先得。ReentrantLock 提供了公平和非公平锁的实现。
1 | // 公平锁 |
例如,假设线程 A 已经持有了锁,这时候线程 B 请求该锁其将会被挂起。当线程 A 释放锁后,假如当前有线程 C 也需要获取该锁,如果采用非公平锁方式,则根据线程调度策略,线程 B 和 线程 C 两者之一可能获取锁,这时候不需要任何其他干涉,而如果使用 公平锁则需要把 C 挂起,让 B 获取当前锁。在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。
独占锁与共享锁
根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占方式实现的。共享锁则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作。
独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读 操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线 程必须等待当前线程释放锁才能进行读取。共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
可重入锁
当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线 程再次获取它自己已经获取的锁时是否会被阻塞呢 ? 如果不被阻塞,那么我们说该锁是可 重入的,也就是只要该线程获取了该锁,那么可以无限次数 (严格来说是有限次数) 地进入被该锁锁住的代码。
1 | public class Hello { |
在如上代码中,调用 helloB 方法前会先获取内置锁,然后打印输出。之后调用 helloA 方法,在调用前会先去获取内置锁,如果内置锁不是可重入的,那么调用线程将会一直被阻塞。实际上,synchronized 内部锁是可重入锁。可重入锁的原理是在锁内部维护一个线程标识,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为 0, 说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成 1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加 +1, 当释放锁后计数器值 -1。当计数器值为 0 时,锁里面的线程标示被重置为 null,这时候被 阻塞的线程会被唤醒来竞争获取该锁。
自旋锁
由于 Java 中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比 如独占锁)失败后,会被切换到内核状态而被挂起。当该线程获取到锁时又需要将其切换 到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度 上会影响并发性能。自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有, 它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可 以使用 -XX:PreBlockSpinsh 参数设置该值),很有可能在后面几次尝试中其他线程已经释放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自 旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间白白浪费 了。